Skip to content

fix(profiles, nav): commit identity atomically, and stop navigation losing entries - #175

Merged
RXWatcher merged 7 commits into
Silo-Server:mainfrom
RXWatcher:pr/profiles-and-navigation
Aug 6, 2026
Merged

fix(profiles, nav): commit identity atomically, and stop navigation losing entries#175
RXWatcher merged 7 commits into
Silo-Server:mainfrom
RXWatcher:pr/profiles-and-navigation

Conversation

@RXWatcher

@RXWatcher RXWatcher commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Four fixes across profile identity and navigation. The common thread is state that was written in two steps, or read from a place that could not answer.

Profile identity is committed as one thing

selectProfile wrote the profile id and its token separately. A failure or a concurrent switch between the two left the app holding one profile's id with another's token — an identity that never existed. Both now commit inside the identity transition, under a scope guard that refuses the write if the surrounding scope moved while it was in flight.

The guard needed care: IdentityTransitionBarrier.changing() bumps the generation before running its block, so a naive comparison rejects every protected-profile login. It compares against the generation the transition itself established.

verifyPin is now a pure query. It was mutating token state as a side effect of asking a question, which meant a failed PIN entry could leave scope changed.

launchSingleTop was silently replacing entries

AndroidX matches launchSingleTop on the destination node, not its arguments — so navigating from one item's detail to another's reused the same entry, and the back stack quietly lost the first. The same applied to the player. Both now compare arguments before deciding it is really the same destination.

Related AndroidX behaviour this also accounts for: restoreState is evaluated before launchSingleTop; saveState on an inclusive pop keys the saved stack to the lowest popped destination; and an existing saved mapping is retained rather than replaced.

Deep links, tab roots and identity reads

Three gaps closed together: a deep link could land on a tab root that had not been created, a tab anchor was read from a stale source rather than the live back stack, and an identity read could run before the identity it described was committed. The vanished-tab case is unified through clearBackStack.

External routes are attributed to the identity that created them

A notification or content link created under one identity could be opened under another — different server, different profile — and silently act on the wrong account's data. Routes now carry an ExternalRouteScope, and a scoped route only opens under a matching identity.

Attribution compares serverId and identityGeneration, deliberately not credentialEpoch, which moves on ordinary token writes and would have invalidated legitimate routes.

Testing

Full suite green on all four modules. Reviewed with Codex across multiple passes; the scope-guard generation bug above was caught that way, and the test double that hid it was rewritten against the real barrier.

TV detail focus restoration is deliberately not in this PR — it is disjoint from these files and goes separately.

🤖 Generated with Claude Code

https://claude.ai/code/session_0115uaQ6FTQZK8KYvazjefaW

Summary by CodeRabbit

  • New Features
    • Improved device-pairing links, including server matching, server switching, and unknown-server guidance.
    • External links and notifications now open only in the correct server and profile context.
    • Added safer handling for links received during authentication or app restarts.
    • Improved tab switching and duplicate navigation behavior on mobile and TV.
    • Enhanced deep-link support for content IDs containing special characters.
  • Bug Fixes
    • Prevented stale profile, PIN, playback, and notification data from being applied after account changes.
    • Improved playback replacement and duplicate prevention on TV.

RXWatcher and others added 4 commits August 6, 2026 07:50
Audit of the profile/PIN path found the client could claim one profile
while holding another's proof, and could act on a verification the user
had already abandoned.

- selectProfile now writes id and token together. Previously it set only
  the id, so phone carried the PREVIOUS profile's token into the new
  selection and every request went out as X-Profile-Id: B with A's
  X-Profile-Token. TV escaped it only because its switch path happened to
  call clearProfile() first. Both real token managers do this in one lock
  and one preferences edit, so a crash cannot persist a mismatch.

- verifyPin no longer touches TokenManager. It used to persist the token
  into whatever server slot was active when the response landed, so
  cancelling mid-flight or switching servers could install one server's
  profile token as another's. It is now a pure query and the caller
  commits the answer.

- Both selection ViewModels gained a generation guard around the verify
  round trip. Cancel during verification still entered the profile.

- Verification is gated on an issued token, not a bare valid=true, so the
  client cannot enter a protected profile holding nothing to present.

- Profile commits are refused outright while a remote-playback overlay
  owns identity, and the managers refuse to merge into an overlay.

- Profile list responses are dropped if the identity they were fetched
  under has been replaced; a stale grid let the user pick a profile from
  a session the app no longer holds.

- Phone kept the raw PIN in rememberSaveable, which the OS serializes
  across process death. Now plain remember.

Server side was verified against the running prod revision 8bde6f11
(== upstream/main HEAD): canManageHousehold requires admin or an active
primary profile, plus a valid X-Profile-Token when that primary has a
PIN, so the PIN boundary is enforced server-side and none of the above
was a privilege escalation.

Known and deliberately left: paired READS still take the lock separately
(AuthInterceptorImpl, MediaAuthSession, PlaybackRealtimeClient), the
ServerRegistry second durable write, TV profile-picker sign-out not
clearing saved credentials during an overlay, and no picker reload on
TEMPORARY_SCOPE_END.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115uaQ6FTQZK8KYvazjefaW
…ntries

`launchSingleTop` matches the destination NODE, not its arguments, and every
item-detail route shares one node. So navigating from detail A to a related
item B reused A's entry instead of pushing, and Back from B skipped A
entirely. The comment at the call site asserted the opposite.

- Item detail now collapses only an exact repeat (same contentId AND season),
  which is what that comment claimed launchSingleTop did. Applied to related
  items, series, seasons, person detail, both collection screens, the shell,
  and the silo://item deep link. Season paging keeps its deliberate
  popUpTo replace.

- Playback navigation is worse under single-top: AndroidX reuses the entry, so
  its ViewModelStore survives and the previous title's player ViewModel can
  live on beside the new one. Home Play had no guard at all while detail Play
  had one, so a fast double Select stacked two players and two sessions.
  All playback now goes through one helper that suppresses only when the entry
  THIS request created is still on top, and otherwise takes over the current
  player rather than stacking. Weaker keys were tried and rejected: the route
  alone ignores what is on top, and route+contentId collides when another path
  puts up the same title with different arguments.

- Watch Together's player target now shares that bookkeeping instead of
  single-topping the current player in place, which preserved the entry id and
  could make a stale record look current.

- Cast launch replaces whichever player is on top, not only the video one; it
  stacked over an audiobook and Back resurrected it.

- Content ids are percent-encoded in routes on both clients, and the phone
  deep-link parser reads rawPath and decodes exactly one segment instead of
  interpolating an already-decoded value back into a route — silo://item/
  abc%3FseasonNumber%3D9 injected a route argument. playerRouteIntentOrNull
  decodes back so an already-showing player still matches.

- Phone route encoding uses java.net rather than android.net.Uri, which is
  stubbed under plain JVM tests and silently yields "item/null"; that had
  already broken two existing suites mid-change.

- Duplicate-nav guards added to the remaining argument-free destinations, and
  two dead launchSingleTop options removed (popUpTo is evaluated first, so
  they could never match).

Seven review rounds; five returned NO-GO on the playback suppression key
before it was keyed on back-stack entry identity with arrival confirmation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115uaQ6FTQZK8KYvazjefaW
The remaining findings from the navigation and profile audits.

- Bottom-nav tab switching popped to a hard-coded Home. An offline launch
  with downloads starts on Downloads, and popping to a route that is not on
  the stack pops nothing, so every tab stacked and Back walked back through
  previously visited tabs instead of leaving. The anchor is now read from
  the live back stack, because neither a hard-coded route nor the graph's
  declared start survives that tab later disappearing.

- A tab that can no longer be shown is now removed rather than left at the
  bottom of the stack, where Back revealed it and its own effect bounced
  straight back. This also covers the anchor vanishing while the user is on
  a different tab, which nothing previously noticed. No saveState on that
  path: restoreState is evaluated before launchSingleTop, so saving the
  vanishing tab and restoring on the way out immediately restored the
  subtree that had just been popped.

- Cold device links were the graph's start destination, so a signed-out user
  got Pair Device at the root; its Sign In pushed Login, and the successful
  login cleared the stack and lost the pairing request. They are queued as
  pending external routes instead, so the normal gates run and pairing
  arrives on an authenticated stack with somewhere to go back to. The
  PairDevice destination's navDeepLinks are gone — Navigation matched the
  launch Intent itself when the graph was installed, which was the same
  bypass.

- HTTPS device links carry the origin that issued the pairing request and it
  was discarded, so the code was looked up against whichever server happened
  to be active. The origin is now parsed (typed, default ports normalized),
  checked, and carried on the pending request so it survives the wait
  through setup and login and is re-checked at delivery. A link naming a
  different configured server is refused; that refusal is silent, which is
  recorded as a known gap needing a product decision.

- The launch Intent was re-parsed on every Activity recreation, pulling the
  user back to a link they had already followed. Delivery is now recorded on
  the Intent for in-process recreation and in saved state for process death.

- Profile id and token are read as one identity in the three places that
  assemble both into a request. The write was already atomic; separate reads
  could still pair an old id with a new token across a switch.

Test doubles delegating TokenManager needed getProfileIdentity overrides:
interface delegation forwards a default method to the delegate, so they were
silently exercising the wrong identity while staying green.

Eleven review rounds. The last five were all one root cause — save/restore
state on a tab that can no longer be shown — and each fix exposed the next.

Unrelated: androidApp PlayerViewModelLoadOwnershipIntegrationTest >
exitDuringDeferredLoadRejectsAndStopsLateReady is a pre-existing flake,
timing out at 5s on roughly two runs in three, including on a clean HEAD.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115uaQ6FTQZK8KYvazjefaW
Closes the three items left open after the navigation work.

The flaky player test. It was not load — it failed 2 in 6 on an idle
machine. Its wait helper hopped to Dispatchers.Default and polled in real
time while the work ran on UnconfinedTestDispatcher, which resumes
inline or queues on the thread's unconfined event loop; the poller could
win that race and time out with the continuation still queued. Every test
in the class was exposed. It now uses StandardTestDispatcher, waits on
replayable StateFlow signals instead of polling, and keeps a real-time
deadline only to turn a hang into a failure. That deadline is 30s, not
5s: five was tight enough that a full-suite run could blow it while the
work was merely slow, which looks exactly like the race being removed.
Also fixed a test whose final wait was already true when called, so it
asserted nothing. Mutation-verified twice; 12 consecutive runs clean.

Notifications acted on whoever was signed in when they were tapped,
which for a PendingIntent can be days and several profile switches
later. They now carry the identity that generated them, established from
the fetched row and a scope that held across the fetch, or not at all —
a partial identity is worse than none, because a missing component is a
wildcard at delivery. Unattributable ones still post so the user knows
something happened, but carry no route, no identity and no item text.
Delivery independently requires a complete identity, so a notification
from an older build or an Intent crafted against the exported Activity
cannot navigate.

Content deep links are server-local, so they pin to the identity active
when the link arrives; arriving signed-out pins nothing, which is what
still lets a link opened before login work after it. Invite claims stay
unpinned — they carry their own target server and are meant to work
pre-auth.

A pairing link naming a server other than the active one was refused
silently: the user scanned a code and nothing happened. The route now
carries its issuing origin and the screen says "This pairing request is
for <server>" with a switch action, or offers to add an unknown one.
Refusing to look a code up against the wrong server is preserved — the
screen owns that check now. The request survives the sign-in the switch
may require, on all three paths that can trigger one.

Two production bugs found while doing it: snapshotCurrentScope was the
one identity read that did not reconcile with the registry first, so
straight after a registry-driven switch it described the previous
server; and a device-shaped link whose origin could not be read parsed
into a route with no origin, which downstream reads as "names no server"
and pairs against whatever is active.

Known limit, recorded in the code: the push payload carries no issuing
server and the FCM token stays registered with previously-active
servers, so a push from A arriving while B is active cannot be
attributed at all and posts non-navigable. Fixing that needs issuer
fields in the push protocol — server-side work.

Seven review rounds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115uaQ6FTQZK8KYvazjefaW
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@RXWatcher, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 39 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2d553e98-8818-4dee-9123-3874b7688765

📥 Commits

Reviewing files that changed from the base of the PR and between cf938ab and 07f48d0.

📒 Files selected for processing (8)
  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt
  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/ExternalRouteNavigation.kt
  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionViewModel.kt
  • androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/push/PushNotificationDeliveryIntegrationTest.kt
  • androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/ExternalRouteNavigationTest.kt
  • androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionGridScopeTest.kt
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt
  • androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionGridScopeTest.kt
📝 Walkthrough

Walkthrough

The change adds atomic profile identity APIs, scope-guarded profile selection, lifecycle-safe external routes, server-aware device pairing, notification attribution checks, and duplicate-safe Android and TV navigation.

Changes

Identity and profile authentication

Layer / File(s) Summary
Atomic profile identity access
shared/src/commonMain/..., shared/src/androidMain/..., android-shared/...
TokenManager exposes paired profile credentials. Consumers read the profile ID and token from one identity snapshot.
Scope-validated profile selection
shared/src/commonMain/..., androidApp/..., androidTvApp/...
PIN verification returns a usable profile token. Profile commits validate identity scope and reject stale or temporary-scope updates.
Notification attribution
androidApp/src/androidMain/..., androidApp/src/androidUnitTest/...
Notifications include profile and server metadata only when attribution remains stable during the notification lookup.

External routing and navigation

Layer / File(s) Summary
External route parsing and delivery
androidApp/src/androidMain/..., androidApp/src/androidUnitTest/...
Activity routes track consumption across recreation and process death. Notifications and content links carry identity scope. Device links carry normalized server origins.
Android navigation and pairing
androidApp/src/androidMain/..., androidApp/src/androidUnitTest/...
Navigation validates scopes after waits, preserves tab stacks, avoids duplicate destinations, and routes pairing through active, known, or unknown server states.
TV navigation and route encoding
androidTvApp/src/androidMain/..., androidTvApp/src/androidUnitTest/...
TV playback replaces different player requests and suppresses duplicate entry requests. Item and playback route parameters are encoded. TV profile selection uses scope guards.

Sequence Diagram(s)

sequenceDiagram
  participant ExternalIntent
  participant MainActivity
  participant AppNavigation
  participant TokenManager
  ExternalIntent->>MainActivity: deliver route
  MainActivity->>TokenManager: resolve current identity scope
  TokenManager-->>MainActivity: return scope snapshot
  MainActivity->>AppNavigation: queue scoped route
  AppNavigation->>TokenManager: validate scope after navigation wait
  AppNavigation-->>MainActivity: consume or requeue route
Loading

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested reviewers: quick104

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 43.37% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main profile identity and navigation stack fixes in the pull request.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

RXWatcher and others added 2 commits August 6, 2026 08:39
Codex reviewed this branch against its rebased base and returned NO-GO on
four counts. All four are addressed.

Unprotected profile selection was not scope-guarded. Both clients passed
expectedScope = null for a profile with no PIN, which deliberately
disables the guard — leaving the one path with no PIN round trip to
re-establish identity committing unguarded. If another account became
active between the grid being accepted and the commit entering the
barrier, one account's profile id was written into the other's token
slot. The scope the displayed grid was fetched under is now retained and
passed for every selection, protected or not.

An external tab link could retain a live player session indefinitely.
tabSwitchNavOptions saves state so a tab keeps its stack, which is right
for a tab — but a saved player entry keeps its ViewModelStore alive, so
onCleared never runs and the session it owns is never stopped. The save
is keyed to the LOWEST popped destination, so a later clearBackStack on
the player route would not even find it. External tab routes now pop the
player without saving first, so its ordinary teardown runs. This is the
same class of leak the session-ownership work exists to prevent.

External item links still used unconditional launchSingleTop, which is
the exact defect this branch fixes for in-app navigation: AndroidX
matches the destination node, not its arguments, so a notification for
item B while item A's detail was showing reused A's entry and Back
skipped A. Single-top now requires the arguments to agree.

ExternalRouteScope did not carry identityGeneration, so signing out and
back into the same account — or A to B back to A — was accepted. Ids
alone cannot tell a new session from the old one. The generation is now
captured, compared, and constrains only when it was known, the same rule
the ids follow. Deliberately not credentialEpoch, which moves on ordinary
token writes and would kill legitimate routes after a routine refresh.

The PR description claimed this comparison already happened. It did not;
that claim was wrong and is now true.

Tests added for the generation rule and for argument-aware external item
links. Full suite green on all four modules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115uaQ6FTQZK8KYvazjefaW
…igation

# Conflicts:
#	androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (11)
shared/src/commonTest/kotlin/org/siloserver/silo/repository/ProfileIdentityCommitTest.kt (1)

32-34: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Close noOpClient after the tests run.

HttpClient owns an engine and a coroutine scope. This instance is never closed, so each test instance leaks one client. SiloAuthPluginPinTest in the same module closes its clients. Add an @AfterTest teardown.

♻️ Proposed teardown
     private val noOpClient = HttpClient(MockEngine { _ ->
         respond(content = "{}", status = HttpStatusCode.OK, headers = headersOf("Content-Type", "application/json"))
     })
+
+    `@AfterTest`
+    fun tearDown() {
+        noOpClient.close()
+    }

Add the import:

 import kotlin.test.Test
+import kotlin.test.AfterTest
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@shared/src/commonTest/kotlin/org/siloserver/silo/repository/ProfileIdentityCommitTest.kt`
around lines 32 - 34, Add an `@AfterTest` teardown method to
ProfileIdentityCommitTest that closes the noOpClient HttpClient after each test
instance, importing the required teardown annotation and preserving the existing
client setup.
androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionViewModel.kt (2)

52-68: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move gridScope so the @param clearError KDoc stays on loadProfiles.

The KDoc at lines 52-56 documents clearError. The new gridScope property was inserted between that block and loadProfiles, so the clearError documentation is now attached to gridScope. gridScope has no clearError parameter, and the tag will not resolve.

♻️ Proposed reordering
+    /**
+     * The identity the displayed grid was fetched under.
+     *
+     * Every selection is qualified with it, protected or not. Passing null for
+     * unprotected picks disabled the guard for exactly the case with no PIN
+     * round trip to re-establish scope — so if another account became active
+     * between the grid being accepted and the commit entering the barrier, one
+     * account's profile id was written into the other's token slot.
+     */
+    private var gridScope: AuthScopeSnapshot? = null
+
     /**
      * `@param` clearError false keeps an existing error banner (e.g. a failed
      * delete's explanation) visible across the follow-up list refresh, which
      * would otherwise silently swallow it.
      */
-    /**
-     * The identity the displayed grid was fetched under.
-     *
-     * Every selection is qualified with it, protected or not. Passing null for
-     * unprotected picks disabled the guard for exactly the case with no PIN
-     * round trip to re-establish scope — so if another account became active
-     * between the grid being accepted and the commit entering the barrier, one
-     * account's profile id was written into the other's token slot.
-     */
-    private var gridScope: AuthScopeSnapshot? = null
-
     fun loadProfiles(clearError: Boolean = true) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionViewModel.kt`
around lines 52 - 68, Move the gridScope property and its KDoc before the
clearError KDoc, keeping the clearError documentation immediately above
loadProfiles. Ensure gridScope retains its identity-scope documentation and
loadProfiles remains the symbol documented by `@param` clearError.

271-289: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

ScopeChanged empties the grid but keeps gridScope in both ViewModels. Both files state the invariant that a captured scope must not outlive the grid it was captured for, and both loadProfiles implementations honor it on the identity-mismatch path. The ScopeChanged handlers do not. If the follow-up loadProfiles() fails, the empty grid keeps a scope from an identity the app no longer holds.

  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionViewModel.kt#L271-L289: set gridScope = null before the _uiState.update block in the ScopeChanged branch of selectProfile.
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt#L207-L224: set gridScope = null before the _uiState.update block in the ScopeChanged branch of commitSelection.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionViewModel.kt`
around lines 271 - 289, Clear gridScope whenever ScopeChanged empties the
profile grid: in
androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionViewModel.kt
lines 271-289, set gridScope to null before the _uiState.update block in
selectProfile; likewise in
androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt
lines 207-224, set gridScope to null before the _uiState.update block in
commitSelection.
androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt (1)

253-255: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider dropping launchSingleTop on the lobby branch.

TvRoute.WatchTogetherLobby.ROUTE carries a {roomId} argument, so launchSingleTop matches the destination node and not the room. If a lobby entry is ever on top when a second room is opened, Navigation reuses that entry and rewrites its arguments. That is the same node-versus-arguments mismatch this PR removes for item detail and playback.

I could not reach this state from the supplied context: TvWatchTogetherLobbyScreen exposes only onNavigateToPlayer and onBack, and the other entry points run while Main or ItemDetail is on top. Treat this as consistency hardening rather than a live defect.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt`
around lines 253 - 255, Remove launchSingleTop from the lobby navigation branch
in the surrounding navigation logic, and invoke navigate(destination) normally
for TvRoute.WatchTogetherLobby.ROUTE so each roomId creates a distinct lobby
entry.
androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherSurfaceSourceTest.kt (1)

40-45: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Both assertions match the current source; consider a behavioral test for the routing decision.

TvAppNavigation.kt contains navigateToTvWatchTogether(snapshot, lastPlaybackNavigation) at line 905 and tvWatchTogetherDestination(room) at line 245, so these pass.

The assertions bind to identifier names inside the production source. Renaming the lambda parameter snapshot, or the navigateToTvWatchTogether parameter room, fails the test without any behavior change. tvWatchTogetherDestination is a pure function of RoomSnapshot, so a direct assertion on its returned route for a lobby snapshot and a player snapshot would pin the same contract without depending on source text. This matches the pattern the new TvItemDetailNavigationTest uses.

The rest of this class already uses source-text assertions, so this is a pre-existing pattern rather than something the PR introduces.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherSurfaceSourceTest.kt`
around lines 40 - 45, Replace the identifier-based source-text assertions in
TvWatchTogetherSurfaceSourceTest with behavioral coverage of the routing
decision: directly verify that tvWatchTogetherDestination returns the expected
route for both lobby and player RoomSnapshot inputs, following the approach used
by TvItemDetailNavigationTest. Keep coverage focused on the destination contract
rather than parameter or implementation names.
androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt (1)

66-68: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Add explicit coverage for percent in TV route arguments.

Encoding contentId, roomId, and title with URLEncoder is appropriate for these route segments, but add a TV route test round-tripping a literal % as %25. The TvAppNavigation matchers already compare decoded arguments, and Navigation Compose 2.9.8 decodes route arguments, so there is no code change needed if that coverage is added.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt`
around lines 66 - 68, Add a TV navigation route test covering a literal percent
sign in the encoded contentId, roomId, and title arguments, asserting the route
contains %25 and the TvAppNavigation matchers receive the original decoded
values. Reuse the existing route-building and matcher test patterns; no
production code changes are needed.
androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt (1)

42-64: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Two builders produce the pair_device route with different encoders. Route.PairDevice encodes token, code, and serverOrigin with Uri.encode, which leaves : and / unescaped and returns null under plain JVM unit tests. buildPairDeviceRoute encodes the same three arguments with a URLEncoder-based helper. Both routes reach the same destination and the same deviceLoginServerMatch comparison, so the two encodings must agree.

  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt#L42-L64: replace the three Uri.encode(it) calls with the file's new it.routeEncode() helper.
  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/DeviceLoginRouteParser.kt#L139-L153: keep the routeEncode encoding, and reuse Route.PairDevice(token, code, serverOrigin).route once both builders share one encoder, so the route exists in one place.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt`
around lines 42 - 64, The two pair-device route builders use inconsistent
encoders. In
androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt#L42-L64,
replace the three Uri.encode calls in Route.PairDevice with the file’s
routeEncode helper. In
androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/DeviceLoginRouteParser.kt#L139-L153,
retain routeEncode and reuse Route.PairDevice(token, code, serverOrigin).route
instead of constructing the route separately.
androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/NotificationExternalRouteTest.kt (1)

55-63: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add the blank profileId case.

This test covers a blank serverId and a blank route. It does not cover a blank profileId. That guard is the third of the three checks that stop a wildcard identity scope, so pin it as well.

💚 Proposed addition
         assertNull(
             notificationExternalRouteOrNull(route = "  ", serverId = "server-a", profileId = "kids"),
         )
+        assertNull(
+            notificationExternalRouteOrNull(route = "item/abc", serverId = "server-a", profileId = " "),
+        )
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/NotificationExternalRouteTest.kt`
around lines 55 - 63, Add a third assertion to the `blank is not an identity`
test for `notificationExternalRouteOrNull`, using a valid route and serverId
with a whitespace-only profileId, and assert that it returns null. Keep the
existing blank serverId and route cases unchanged.
androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/ExternalRouteNavigation.kt (1)

118-124: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the shared route-segment decode into one helper.

isSameItemDetail (lines 118-125) and playerRouteIntentOrNull (line 147) use the identical decode expression, including the + preservation step. Both values feed destination-identity comparisons. If one copy changes, the item path and the player path compare differently. Extract a single private helper and call it from both.

♻️ Proposed refactor
+private fun String.decodeRouteSegmentOrNull(): String? =
+    runCatching {
+        URLDecoder.decode(replace("+", "%2B"), StandardCharsets.UTF_8.name())
+    }.getOrNull()?.takeIf(String::isNotBlank)
     val targetContentId = targetRoute
         .substringAfter("item/")
         .substringBefore('?')
         .takeIf { it.isNotBlank() }
         // Same decode as the player intent: the route percent-encodes the id,
         // and an encoded id never equals the decoded one held by the entry.
-        ?.let {
-            runCatching {
-                URLDecoder.decode(it.replace("+", "%2B"), StandardCharsets.UTF_8.name())
-            }.getOrNull()
-        }
-        ?.takeIf { it.isNotBlank() }
+        ?.decodeRouteSegmentOrNull()
         ?: return false

Apply the same call at line 147 in playerRouteIntentOrNull.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/ExternalRouteNavigation.kt`
around lines 118 - 124, Extract the shared percent-encoded route-segment
decoding, including the plus-sign preservation and failure handling, into a
single private helper in ExternalRouteNavigation. Replace the inline decode in
isSameItemDetail and the identical expression in playerRouteIntentOrNull with
calls to that helper.
androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/ExternalRouteNavigationTest.kt (1)

414-428: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pin the null live generation case.

This test covers a pinned generation against a known live generation. It does not cover a pinned generation against a null live generation. That case occurs in AppNavigation.kt when tokenManager.snapshotCurrentScope() returns null, and matches then refuses the route. Add the assertion so the fail-closed direction stays pinned.

💚 Proposed addition
         assertFalse(
             scope.matches(serverId = "server-a", profileId = "kids", identityGeneration = 8L),
         )
+        // No live scope at all must not satisfy a generation-pinned route.
+        assertFalse(
+            scope.matches(serverId = "server-a", profileId = "kids", identityGeneration = null),
+        )
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/ExternalRouteNavigationTest.kt`
around lines 414 - 428, The test
aScopePinnedToAGenerationDoesNotMatchALaterSession should also assert that a
pinned Identity scope does not match when the live identityGeneration is null,
preserving the fail-closed behavior used by AppNavigation when
snapshotCurrentScope returns null. Add the null-generation matches assertion
alongside the existing generation checks.
androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/ContentDeepLinkEncodingTest.kt (1)

72-76: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use kotlin.test.assertNotEquals instead of the local helper.

The test uses kotlin 2.1.20 and already imports sibling assertions from kotlin.test; add import kotlin.test.assertNotEquals, remove the private helper, and keep the call at line 29 as assertNotEquals("item/abc?seasonNumber=9", route) for String?.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/ContentDeepLinkEncodingTest.kt`
around lines 72 - 76, In ContentDeepLinkEncodingTest, import
kotlin.test.assertNotEquals and remove the local private assertNotEquals helper.
Keep the existing assertion call unchanged so it uses the Kotlin test assertion
with the nullable String route value.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@androidApp/src/androidMain/kotlin/org/siloserver/silo/android/MainActivity.kt`:
- Around line 234-241: Update queueExternalRouteFrom so its
pendingExternalRouteRequests write occurs only when the processed Intent is
still the current activity Intent after currentIdentityScope() resumes. Ignore
stale coroutine results when a newer Intent has replaced it, preserving the
latest route in onNewIntent.

In
`@androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt`:
- Around line 630-639: Update the start method’s requestCount publication so the
value is assigned atomically within the synchronized(pending) block, ensuring
overlapping calls cannot publish a stale lower count after a higher one. Keep
the existing Pending registration and awaitRequestCount behavior unchanged.

In
`@shared/src/androidMain/kotlin/org/siloserver/silo/network/EncryptedTokenManagerImpl.kt`:
- Around line 271-289: Update setProfileIdentity so its mutex-protected block
first calls ensureCacheMatchesRegistryLocked(), before reading temporaryScope or
activeServerId; then retain the existing temporary-scope guard and profile-pair
persistence using the reconciled server context.

---

Nitpick comments:
In
`@androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/ExternalRouteNavigation.kt`:
- Around line 118-124: Extract the shared percent-encoded route-segment
decoding, including the plus-sign preservation and failure handling, into a
single private helper in ExternalRouteNavigation. Replace the inline decode in
isSameItemDetail and the identical expression in playerRouteIntentOrNull with
calls to that helper.

In
`@androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt`:
- Around line 42-64: The two pair-device route builders use inconsistent
encoders. In
androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt#L42-L64,
replace the three Uri.encode calls in Route.PairDevice with the file’s
routeEncode helper. In
androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/DeviceLoginRouteParser.kt#L139-L153,
retain routeEncode and reuse Route.PairDevice(token, code, serverOrigin).route
instead of constructing the route separately.

In
`@androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionViewModel.kt`:
- Around line 52-68: Move the gridScope property and its KDoc before the
clearError KDoc, keeping the clearError documentation immediately above
loadProfiles. Ensure gridScope retains its identity-scope documentation and
loadProfiles remains the symbol documented by `@param` clearError.
- Around line 271-289: Clear gridScope whenever ScopeChanged empties the profile
grid: in
androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionViewModel.kt
lines 271-289, set gridScope to null before the _uiState.update block in
selectProfile; likewise in
androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt
lines 207-224, set gridScope to null before the _uiState.update block in
commitSelection.

In
`@androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/ContentDeepLinkEncodingTest.kt`:
- Around line 72-76: In ContentDeepLinkEncodingTest, import
kotlin.test.assertNotEquals and remove the local private assertNotEquals helper.
Keep the existing assertion call unchanged so it uses the Kotlin test assertion
with the nullable String route value.

In
`@androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/ExternalRouteNavigationTest.kt`:
- Around line 414-428: The test
aScopePinnedToAGenerationDoesNotMatchALaterSession should also assert that a
pinned Identity scope does not match when the live identityGeneration is null,
preserving the fail-closed behavior used by AppNavigation when
snapshotCurrentScope returns null. Add the null-generation matches assertion
alongside the existing generation checks.

In
`@androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/NotificationExternalRouteTest.kt`:
- Around line 55-63: Add a third assertion to the `blank is not an identity`
test for `notificationExternalRouteOrNull`, using a valid route and serverId
with a whitespace-only profileId, and assert that it returns null. Keep the
existing blank serverId and route cases unchanged.

In
`@androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt`:
- Around line 253-255: Remove launchSingleTop from the lobby navigation branch
in the surrounding navigation logic, and invoke navigate(destination) normally
for TvRoute.WatchTogetherLobby.ROUTE so each roomId creates a distinct lobby
entry.

In
`@androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt`:
- Around line 66-68: Add a TV navigation route test covering a literal percent
sign in the encoded contentId, roomId, and title arguments, asserting the route
contains %25 and the TvAppNavigation matchers receive the original decoded
values. Reuse the existing route-building and matcher test patterns; no
production code changes are needed.

In
`@androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherSurfaceSourceTest.kt`:
- Around line 40-45: Replace the identifier-based source-text assertions in
TvWatchTogetherSurfaceSourceTest with behavioral coverage of the routing
decision: directly verify that tvWatchTogetherDestination returns the expected
route for both lobby and player RoomSnapshot inputs, following the approach used
by TvItemDetailNavigationTest. Keep coverage focused on the destination contract
rather than parameter or implementation names.

In
`@shared/src/commonTest/kotlin/org/siloserver/silo/repository/ProfileIdentityCommitTest.kt`:
- Around line 32-34: Add an `@AfterTest` teardown method to
ProfileIdentityCommitTest that closes the noOpClient HttpClient after each test
instance, importing the required teardown annotation and preserving the existing
client setup.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: a43d0a32-5670-4777-a04c-99f2e890dbc5

📥 Commits

Reviewing files that changed from the base of the PR and between b9c4fa2 and cf938ab.

📒 Files selected for processing (39)
  • android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/MediaAuthSession.kt
  • android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/network/WatchTogetherRealtimeWebSocketTest.kt
  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/MainActivity.kt
  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/di/AndroidModule.kt
  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/push/PushNotificationPresenter.kt
  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/AppNavigation.kt
  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/BottomNavBar.kt
  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/ContentDeepLinkRoutes.kt
  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/DeviceLoginRouteParser.kt
  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/DeviceLoginServerMatch.kt
  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/ExternalRouteNavigation.kt
  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/NotificationExternalRoute.kt
  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/navigation/Routes.kt
  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/MainScreen.kt
  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/auth/DevicePairingWrongServerScreen.kt
  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/PINEntryDialog.kt
  • androidApp/src/androidMain/kotlin/org/siloserver/silo/android/ui/screens/profiles/ProfileSelectionViewModel.kt
  • androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/push/PushNotificationAttributionTest.kt
  • androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/ContentDeepLinkEncodingTest.kt
  • androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/DeviceLoginRouteParserTest.kt
  • androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/DeviceLoginServerMatchTest.kt
  • androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/ExternalRouteNavigationTest.kt
  • androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/navigation/NotificationExternalRouteTest.kt
  • androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvAppNavigation.kt
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/navigation/TvRoute.kt
  • androidTvApp/src/androidMain/kotlin/org/siloserver/silo/tv/ui/screens/profiles/TvProfileSelectionViewModel.kt
  • androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/navigation/TvItemDetailNavigationTest.kt
  • androidTvApp/src/androidUnitTest/kotlin/org/siloserver/silo/tv/ui/screens/watchtogether/TvWatchTogetherSurfaceSourceTest.kt
  • shared/src/androidMain/kotlin/org/siloserver/silo/network/EncryptedTokenManagerImpl.kt
  • shared/src/androidUnitTest/kotlin/org/siloserver/silo/network/EncryptedTokenManagerScopeGenerationTest.kt
  • shared/src/commonMain/kotlin/org/siloserver/silo/model/profile/ProfileModels.kt
  • shared/src/commonMain/kotlin/org/siloserver/silo/network/AuthInterceptorImpl.kt
  • shared/src/commonMain/kotlin/org/siloserver/silo/network/PlaybackRealtimeClient.kt
  • shared/src/commonMain/kotlin/org/siloserver/silo/network/TokenManager.kt
  • shared/src/commonMain/kotlin/org/siloserver/silo/network/TokenManagerImpl.kt
  • shared/src/commonMain/kotlin/org/siloserver/silo/repository/ProfileRepository.kt
  • shared/src/commonTest/kotlin/org/siloserver/silo/network/SiloAuthPluginPinTest.kt
  • shared/src/commonTest/kotlin/org/siloserver/silo/repository/ProfileIdentityCommitTest.kt

Comment on lines 234 to +241
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
// A genuinely new Intent has not been consumed, whatever the old one
// carried.
intent.removeExtra(EXTRA_EXTERNAL_ROUTE_CONSUMED)
consumedExternalRoute = null
setIntent(intent)
val route = deviceLoginPairRouteOrNull(intent.dataString)
?: inviteClaimRouteOrNull(intent.dataString)
?: notificationRouteOrNull(intent)
?: contentDeepLinkRouteOrNull(intent.dataString)
route?.let { pendingExternalRouteRequests.value = externalRouteRequestFactory.create(it) }
lifecycleScope.launch { queueExternalRouteFrom(intent) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

A second Intent during the suspension can restore an older route.

queueExternalRouteFrom suspends at currentIdentityScope(). If a second Intent arrives while the first coroutine is suspended, the second coroutine can finish first. The first one then resumes and overwrites pendingExternalRouteRequests with the older route. The user lands on the previous link instead of the one just opened.

Bind the write to the Intent that is still current.

🐛 Proposed fix: drop a result whose Intent is no longer current
-    private suspend fun queueExternalRouteFrom(intent: Intent?) {
+    private suspend fun queueExternalRouteFrom(intent: Intent?) {
         if (intent?.getBooleanExtra(EXTRA_EXTERNAL_ROUTE_CONSUMED, false) == true) return
+        // A newer Intent may have arrived while currentIdentityScope() was
+        // suspended; its coroutine may already have published. Do not clobber
+        // it with this older route.
+        if (intent !== this.intent) return
         pendingExternalRouteRequests.value =
             externalRouteRequestFactory.create(route = route, scope = scope)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@androidApp/src/androidMain/kotlin/org/siloserver/silo/android/MainActivity.kt`
around lines 234 - 241, Update queueExternalRouteFrom so its
pendingExternalRouteRequests write occurs only when the processed Intent is
still the current activity Intent after currentIdentityScope() resumes. Ignore
stale coroutine results when a newer Intent has replaced it, preserving the
latest route in onNewIntent.

Comment on lines +630 to 639
/** Replayable so a request that lands before the wait begins is still seen. */
private val requestCount = MutableStateFlow(0)

override suspend fun start(request: VideoPlaybackStartRequest): VideoPlaybackStartResult =
suspendCoroutine { continuation ->
synchronized(pending) {
requestCount.value = synchronized(pending) {
pending += Pending(request, continuation)
pending.size
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Publish requestCount inside the lock.

The assignment to requestCount.value runs after synchronized(pending) releases. If two start calls ever overlap, the caller that computed size 1 can assign after the caller that computed size 2, so requestCount regresses from 2 to 1. awaitRequestCount reads the current value through first { it >= count }, so a waiter that begins after the regression blocks until a further request arrives, and the 30-second deadline turns that into a confusing timeout.

The current tests drive loadContent sequentially, so overlapping start calls are not reachable today. The fix is one line and removes the hazard for future tests. stoppedSignal at line 682 already uses update and does not have this problem.

🔒️ Proposed fix to publish the count atomically
     override suspend fun start(request: VideoPlaybackStartRequest): VideoPlaybackStartResult =
         suspendCoroutine { continuation ->
-            requestCount.value = synchronized(pending) {
+            synchronized(pending) {
                 pending += Pending(request, continuation)
-                pending.size
+                requestCount.value = pending.size
             }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/** Replayable so a request that lands before the wait begins is still seen. */
private val requestCount = MutableStateFlow(0)
override suspend fun start(request: VideoPlaybackStartRequest): VideoPlaybackStartResult =
suspendCoroutine { continuation ->
synchronized(pending) {
requestCount.value = synchronized(pending) {
pending += Pending(request, continuation)
pending.size
}
}
/** Replayable so a request that lands before the wait begins is still seen. */
private val requestCount = MutableStateFlow(0)
override suspend fun start(request: VideoPlaybackStartRequest): VideoPlaybackStartResult =
suspendCoroutine { continuation ->
synchronized(pending) {
pending += Pending(request, continuation)
requestCount.value = pending.size
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@androidApp/src/androidUnitTest/kotlin/org/siloserver/silo/android/ui/screens/player/PlayerViewModelLoadOwnershipIntegrationTest.kt`
around lines 630 - 639, Update the start method’s requestCount publication so
the value is assigned atomically within the synchronized(pending) block,
ensuring overlapping calls cannot publish a stale lower count after a higher
one. Keep the existing Pending registration and awaitRequestCount behavior
unchanged.

Comment on lines +271 to +289
override suspend fun setProfileIdentity(profileId: String?, profileToken: String?) {
mutex.withLock {
// A temporary overlay owns its own identity for the lifetime of a
// remote-playback handoff. Merging a profile commit into it is how
// you get the exact defect this method exists to prevent: writing
// the new profile id beside the overlay's old token. Leave it
// alone; the repository rejects the commit outright.
if (temporaryScope != null) return@withLock
val serverId = activeServerId ?: return
if (this.profileId == profileId && this.profileToken == profileToken) return
this.profileId = profileId
this.profileToken = profileToken
val idKey = serverScopedKey(serverId, KEY_PROFILE_ID)
val tokenKey = serverScopedKey(serverId, KEY_PROFILE_TOKEN)
prefs.edit().apply {
if (profileId == null) remove(idKey) else putString(idKey, profileId)
if (profileToken == null) remove(tokenKey) else putString(tokenKey, profileToken)
}.apply()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Reconcile the active-server cache before writing the profile pair.

After registry.switchTo(B), this method can run before the registry collector updates cached activeServerId. It then writes B's profile identity to A's preference keys and cache. Call ensureCacheMatchesRegistryLocked() before reading temporaryScope or activeServerId.

Proposed fix
 override suspend fun setProfileIdentity(profileId: String?, profileToken: String?) {
     mutex.withLock {
+        ensureCacheMatchesRegistryLocked()
         // A temporary overlay owns its own identity for the lifetime of a
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@shared/src/androidMain/kotlin/org/siloserver/silo/network/EncryptedTokenManagerImpl.kt`
around lines 271 - 289, Update setProfileIdentity so its mutex-protected block
first calls ensureCacheMatchesRegistryLocked(), before reading temporaryScope or
activeServerId; then retain the existing temporary-scope guard and profile-pair
persistence using the reconciled server context.

RXWatcher added a commit to RXWatcher/silo-android that referenced this pull request Aug 6, 2026
Codex reviewed this branch twice and fixed both remaining findings.

The return path did not work on this branch at all. Detail-to-related
navigation used launchSingleTop, which AndroidX matches on the destination
NODE rather than its contentId, so A -> related B reused A's entry and
left no A for Back to reveal — the restoration had nothing to restore to.
The comment there asserted the opposite. Related details now push, which
is the minimum this branch needs; the broader exact-repeat helper stays on
Silo-Server#175 rather than being duplicated here.

The keyed savers are typed rather than erased. The generic version cast
through a type parameter, so `values[1] as T` validated against Any only:
a payload with the right owner and the wrong type passed restore and
failed later where Compose reads it, crashing during restoration instead
of being rejected. Boolean and Int savers now validate surface, slot,
length and type, and the return-target saver rejects malformed payloads
rather than throwing. Each carries a slot name as well as the surface
token, because the owner alone cannot tell one scalar slot from another.

The timeout and revocation policy is extracted so it can be tested
directly, with coverage for the attachment-timeout fallback and mid-loop
revocation — the paths that had none.

Full suite green on all four modules with --rerun-tasks, verified here
rather than taken on trust: Codex's own sandbox could not run Gradle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115uaQ6FTQZK8KYvazjefaW
Codex reviewed this branch twice and fixed the remaining findings itself.
Three of the four problems on the second pass were introduced by my own
remediation, and a fourth turned up here.

The grid scope now moves only with a grid the app actually accepted.
Assigning it before the result meant a reload that FAILED under a new
identity left the old grid on screen qualified by the new scope, so a
profile from the old session committed as belonging to the new one —
worse than the unguarded commit it replaced. Nulling it on a scope
mismatch then opened a second hole: a tap dispatched after the grid
cleared committed unguarded precisely because the scope was null. Both
are closed, and a cleared grid can no longer select at all.

External-route single-top is an explicit per-destination policy rather
than punctuation matching. My version keyed on whether the route
contained '?' or '/', which happened to work for the cases I had in mind
and not for others; the policy now names each destination an external
request can produce — inbox, item, player, downloads, pair_device,
invite_claim — so a second invitation gets its own entry instead of
collapsing onto the first.

The player pop stays conditional on the player being the current
destination. Popping a player that sits below newer history would take
that history with it, which is a worse trade than leaving the rarer case
saved.

Notifications still carry no identity generation, deliberately: that
counter restarts at zero every process, so persisting it into a
PendingIntent would refuse a legitimate notification tapped after the app
was killed. Closing that needs a durable epoch, not a process-local
count. The KDoc says so rather than implying coverage it does not have.

Tests added for every path above, including the ones that had none: a
reload that fails under a new scope, a tap after a cleared grid, the
player pop with the player absent, on top, and below other entries, the
full single-top decision including invite_claim, and notification extras
through to the delivered scope.

Full suite green on all four modules, verified here across repeated
--rerun-tasks runs rather than taken on trust: Codex's sandbox could not
run Gradle and correctly declined to claim it had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0115uaQ6FTQZK8KYvazjefaW
@RXWatcher
RXWatcher merged commit b47e8f3 into Silo-Server:main Aug 6, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant